Skip to content

feat(mix): add content-sized GridTrack.auto() rows - #1031

Merged
leoafarias merged 9 commits into
mainfrom
feat/grid-auto-rows
Aug 19, 2026
Merged

feat(mix): add content-sized GridTrack.auto() rows#1031
leoafarias merged 9 commits into
mainfrom
feat/grid-auto-rows

Conversation

@leoafarias

Copy link
Copy Markdown
Member

Related issue

None filed. Reported directly against main @ 390421be with a reproduction; investigation notes are in .context/grid-content-sized-tracks-findings.md.

Description

GridBox could not express a grid whose rows size to their tallest child, and the only available workaround silently truncated content.

Given a 2-column GridBox with four Text children of unequal height inside a SingleChildScrollView at width 400:

  • no autoRows → threw Grid auto-placement requires an autoRows track
  • .autoRows(.fr(1)) → threw Grid autoRows requires a bounded height for fractional tracks
  • .autoRows(.fixed(40)) → no error, but the long cell was laid out at Size(194, 40) while the same text at the same width needs Size(194, 300)

The third case is the important one: performLayout gave every child a tight cell, so a child taller than its track was clipped with no diagnostic — unlike Column/Wrap, which report visible overflow. Every shipped grid example worked around this with a hardcoded row height (.fixed(116), (104), (230), (210), (220), (190)), so the gallery could only demonstrate content whose height was already known. CSS Grid's default grid-auto-rows is auto; Mix could not express the CSS default.

This adds GridTrack.auto() and makes it the default for implicit rows.

No stock Flutter widget satisfied the contract. Table has the right row-height rule but not GridBox's gaps, fixed/fr row semantics, or flat-child identity across responsive column changes; Wrap lacks shared tracks and stretch; sliver grids require delegate-chosen tile heights; CustomMultiChildLayout cannot size the parent from a measure-then-stretch pass. RenderMixGrid stays the owner and borrows RenderTable's algorithm.

Changes

Public API

  • GridTrack.auto() — a fieldless, content-sized track, valid in rows and autoRows. Columns reject it with a vertical-only hint.
  • An omitted autoRows now resolves to GridTrack.auto(), so a Grid with columns and no row declaration sizes each implicit row to its tallest child.
  • auto interpolates with auto (constant); auto ↔ numeric track kinds snap at the animation midpoint like other incompatible kinds.

Layout (render_grid.dart)

  • Fixed/fr grids keep the existing single-pass path — _hasAutoTrack short-circuits the measure pass, so those children are still laid out once.
  • Auto-row children are measured at their resolved column width with a loose height; each row takes the tallest measurement; the final pass lays every child into the stretched cell so shorter siblings still fill the row.
  • Live layout, dry layout, and vertical intrinsics share the geometry through Flutter's ChildLayoutHelper.layoutChild / dryLayoutChild.
  • computeGridLayout now takes already-resolved rows, so implicit-row expansion happens exactly once per layout.

Protocol

  • Additive v1 {"type":"auto"} nested discriminator, gated to rows, autoRows, and constraint patches. Auto tracks carry no token references. The exported schema fingerprint changes accordingly.

Docs & example

  • The catalog example drops its hardcoded row heights and now renders genuinely unequal card content; both goldens regenerated.
  • doc/grid-layout.md documents the row-only scope, the default for implicit rows, the bounded-height rule for fr rows, why fixed tracks clip by design, and the nesting cost.

Review Checklist

  • Testing: packages/mix 2912 tests, packages/mix/example 15, packages/mix_protocol 393 — all passing. dart analyze clean on all three. New coverage includes the reported scroll-view repro, mixed auto+fixed and auto+fr rows under loose and tight bounded height, responsive remeasurement across an onConstraints breakpoint without rebuilding children, a layout-count assertion proving only auto-row children pay the second pass, live/dry/intrinsic parity, animation, seeded property tests over 200 random grids, and protocol round-trips including lenient decode of an unrecognized track.
  • Breaking Changes: Yes — documented in packages/mix/CHANGELOG.md under ### Breaking changes. See below.
  • Documentation Updates: doc/grid-layout.md, README.md, example/README.md, mix_protocol/GUIDE.md, mix_protocol/WIRE_CONTRACT.md, and the Grid guidance under skills/mix/.
  • Website Updates: not done — btwld/mix-docs needs a matching update for the new track kind and the implicit-row default.

Additional Information

Breaking change. A GridBox that needed more rows than it declared used to throw; it now lays out with content-sized implicit rows. This applies both when no rows are declared and when an explicit rows list undershoots the child count. No currently-successful fixed/fr layout changes.

Deliberately not included: a universal overflow warning for fixed cells. A fixed track is a hard constraint in the same way a tight SizedBox is, and a reliable generic probe would need a speculative measure pass on every fixed child — which also breaks children that require a bounded height. Now that auto exists, fixed rows stay strict, and the docs say so explicitly.

Cost. Auto-row children are laid out twice (measure, then stretch). Nesting compounds this multiplicatively — a leaf inside three nested auto Grids is laid out eight times. This is inherent to the measure-then-stretch algorithm (RenderTable has it too), and is documented in both the guide and the changelog.

Scope. Content-sized columns are deliberately out. Width-dependent wrapping creates feedback between column and row sizing, and the reported use case needs content-height rows after responsive column widths are known. GridTrack.auto() should not be quietly broadened to columns later without specifying those feedback rules — the reasoning for keeping one GridTrack type rather than splitting per axis is recorded as a comment in grid_track.dart.

Wire compatibility. {"type":"auto"} is additive within v1, per WIRE_CONTRACT.md. Old strict decoders fail closed; lenient decoders warn and drop the smallest unrecognized value (now covered by a test). Payloads containing type: auto should only be produced once matching mix and mix_protocol versions ship together.

GridBox could not express a grid whose rows size to their tallest child.
The only workaround, autoRows(GridTrack.fixed(n)), silently truncated any
child taller than the declared track, so a scroll view of unknown-height
content had no correct option.

Add GridTrack.auto(), valid in rows and autoRows, and default an omitted
autoRows to it so implicit rows match CSS's grid-auto-rows: auto. Columns
still reject auto; content-sized columns need a separate two-axis design.

RenderMixGrid keeps the one-pass path for fixed/fr grids. When any
effective row is auto, children in those rows are measured at their
resolved column width with a loose height, each row takes its tallest
measurement, and the final pass lays every child into the stretched cell.
Live layout, dry layout, and vertical intrinsics share that geometry
through Flutter's ChildLayouter helpers, following RenderTable's pattern.

The wire format gains a fieldless {"type":"auto"} track as an additive v1
discriminator, accepted on rows and autoRows only.

BREAKING CHANGE: a GridBox that needed more rows than it declared used to
throw; it now lays out with content-sized implicit rows. This applies both
when no rows are declared and when explicit rows undershoot the child
count.
Three CI failures from the auto-rows change:

- dart format left two over-long isNot(contains(...)) assertions in
  grid_box_test.dart unwrapped.
- DCM's arguments-ordering rule requires child/children last
  (lints_with_dcm.yaml), but _measureAutoRowChild declared and passed
  child first.
- mix_chart_protocol composes the core vocabulary, so its exported v1
  schema fingerprint moved when the grid_box branch gained the auto
  track kind. Verified the delta is confined to grid_box: "auto" appears
  in exactly one of the 26 exported branches.
A fully tight cell constraint made every child its own relayout boundary,
so a child-only change never reached RenderMixGrid and the auto row kept
the height measured on its first layout pass. Give auto-row children a
loose max height with parentUsesSize so the row re-measures, and leave
fixed/fr rows on the tight cell that is their documented contract.

Carry the resolved row tracks on GridLayoutResult so performLayout can
tell the two cases apart, and inline the single-use measure helper.
Move the GridTrack design rationale out of the published dartdoc into a
maintainer comment: it named a private helper and argued against a future
refactor, neither of which belongs on pub.dev.

Replace the only curly quotes in the Dart sources, rewrite two awkward
autoRows doc sentences, drop the stringified Object parameter on the
auto-row error helper, and rewrap two markdown lines that broke the wrap
width their files otherwise keep.
… row

An auto-row child was always laid out a second time, because the final
constraint differed from the measure-pass one only in minHeight (0 -> row
height). RenderObject.layout could not take its early return, so the whole
subtree relaid out even when the measure already produced the row height.

Hand a child that already fills its row the exact constraint it was measured
with. That covers at least the tallest child of every auto row and every child
of a single-child row, so nested auto grids stop multiplying layout passes per
level: a leaf inside three nested auto Grids goes from 8 relayouts to 1. Only
children shorter than the row are laid out again to stretch them.

The relayout-boundary rationale moves into _cellConstraints, with a note to
keep the constraint identical to the measure-pass one.

_RenderLayoutCallCounter now counts performLayout executions, not just layout
calls; the old assertion stayed green through the extra pass because layout()
is still called twice, it just early-returns now.

Also correct the CHANGELOG, doc/grid-layout.md, and the layout skill reference,
which documented the 2^n nesting cost as inherent, and mark the unreachable
fractional branch in _computeAutoRowIntrinsicHeight as such.
The existing relayout test drives height from a parent rebuild, which reaches
the grid through the widget tree whatever constraint the cell carries. That
does not isolate the invariant it claims to protect.

This one changes height entirely below the grid via the child's own setState,
with GridBoxSpec untouched so MixGrid.updateRenderObject no-ops. The only route
back to the grid is markNeedsLayout propagating past the child, which a tight
cell would stop by making the child its own relayout boundary. That models the
real reported case: an async image or an expanding tile inside a scroll view.

Verified as a real guard: restoring the pre-fix constraint (tight cell,
parentUsesSize false) fails it with the row stuck at its first measured
height, 30 where 90 is expected.
The feature entry hedged ("may be measured and then stretched") and repeated
the layout-pass accounting that the breaking-change entry also carried, while
neither said what an auto row actually does or when to reach for one.

State the behaviour once, in the feature entry, and give the breaking entry
what a breaking note is for: the migration. A reader who relied on the throw to
catch an under-declared Grid now gets told to declare rows or set autoRows.

Also drop a redundant clause from _cellConstraints: a single-child row's only
child is by definition its tallest, so naming both cases said one thing twice.
@leoafarias
leoafarias merged commit 8573c0c into main Aug 19, 2026
5 checks passed
@leoafarias
leoafarias deleted the feat/grid-auto-rows branch August 19, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant